iT邦幫忙

2022 iThome 鐵人賽

DAY 30
0
Modern Web

擁抱 .Net Core系列 第 30

[Day30] 錯誤處理 - Exception Handler

  • 分享至 

  • xImage
  •  

不管是因為系統設計的問題(開發者所導致的)
又或者是使用者所使用系統的方式超乎常人想像
一個系統多多少少都會出現錯誤與例外
在開發模式下,可能會想要詳細的錯誤頁面來告知開發人員哪邊有例外
但是在生產環境中,這些例外並不適合對外不公開
公開的話就好比告訴別人,這裡是我的弱點,快來攻擊我
以常見的例外找不到資源404 來說,可能會想要將使用者導向特定頁面表示表示資源不存在
至於更嚴重的500 系列錯誤,代表的是系統內部沒有防範到的例外(或刻意為之)
但這類錯誤就不適合公開,讓我們來看看如何處理這類型的例外吧

例外處理Middleware

在建立一個新的web mvc 專案的範本時
其實微軟已經貼心的幫妳加好了

if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler("/Home/Error");
    // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
    app.UseHsts();
}

內部會去註冊ExceptionHandlerMiddleware 這個例外處理中介軟體
以上面的program.cs 中,只要是開發模式下的例外
都會被導到/Home/Error的自訂頁面

ExceptionHandlerMiddleware

整個Class太長,我們把重點放在invoke 方法上,跟與其相關的Handle
ExceptionHandlerMiddleware.cs
4`

public class ExceptionHandlerMiddleware
{
  public Task Invoke(HttpContext context)
    {
        ExceptionDispatchInfo edi;
        try
        {
            var task = _next(context);
            if (!task.IsCompletedSuccessfully)
            {
                return Awaited(this, context, task);
            }

            return Task.CompletedTask;
        }
        catch (Exception exception)
        {
            edi = ExceptionDispatchInfo.Capture(exception);
        }

        return HandleException(context, edi);

        static async Task Awaited(ExceptionHandlerMiddleware middleware, HttpContext context, Task task)
        {
            ExceptionDispatchInfo? edi = null;
            try
            {
                await task;
            }
            catch (Exception exception)
            {
                edi = ExceptionDispatchInfo.Capture(exception);
            }

            if (edi != null)
            {
                await middleware.HandleException(context, edi);
            }
        }
    }
    
      private async Task HandleException(HttpContext context, ExceptionDispatchInfo edi)
        {
            _logger.UnhandledException(edi.SourceException);
            // We can't do anything if the response has already started, just abort.
            if (context.Response.HasStarted)
            {
                _logger.ResponseStartedErrorHandler();
                edi.Throw();
            }

            PathString originalPath = context.Request.Path;
            if (_options.ExceptionHandlingPath.HasValue)
            {
                context.Request.Path = _options.ExceptionHandlingPath;
            }
            try
            {
                var exceptionHandlerFeature = new ExceptionHandlerFeature()
                {
                    Error = edi.SourceException,
                    Path = originalPath.Value!,
                    Endpoint = context.GetEndpoint(),
                    RouteValues = context.Features.Get<IRouteValuesFeature>()?.RouteValues
                };

                ClearHttpContext(context);

                context.Features.Set<IExceptionHandlerFeature>(exceptionHandlerFeature);
                context.Features.Set<IExceptionHandlerPathFeature>(exceptionHandlerFeature);
                context.Response.StatusCode = StatusCodes.Status500InternalServerError;
                context.Response.OnStarting(_clearCacheHeadersDelegate, context.Response);

                await _options.ExceptionHandler!(context);
             
                if (context.Response.HasStarted || context.Response.StatusCode != StatusCodes.Status404NotFound || _options.AllowStatusCode404Response)
                {
                    if (_diagnosticListener.IsEnabled() && _diagnosticListener.IsEnabled("Microsoft.AspNetCore.Diagnostics.HandledException"))
                    {
                        _diagnosticListener.Write("Microsoft.AspNetCore.Diagnostics.HandledException", new { httpContext = context, exception = edi.SourceException });
                    }

                    return;
                }

                edi = ExceptionDispatchInfo.Capture(new InvalidOperationException($"The exception handler configured on {nameof(ExceptionHandlerOptions)} produced a 404 status response. " +
                    $"This {nameof(InvalidOperationException)} containing the original exception was thrown since this is often due to a misconfigured {nameof(ExceptionHandlerOptions.ExceptionHandlingPath)}. " +
                    $"If the exception handler is expected to return 404 status responses then set {nameof(ExceptionHandlerOptions.AllowStatusCode404Response)} to true.", edi.SourceException));
            }
            catch (Exception ex2)
            {
                // Suppress secondary exceptions, re-throw the original.
                _logger.ErrorHandlerException(ex2);
            }
            finally
            {
                context.Request.Path = originalPath;
            }

            edi.Throw(); // Re-throw wrapped exception or the original if we couldn't handle it
        }
}

大概的重點是,當原先的pipeline有例外發生的時候
會被catch住,並透過自訂管道處理該例外例外
他會將例外的資訊封裝在ExceptionHandlerFeature物件並存到HttpContext.Features
並且會將HttpStatusCode設為500
後續自訂處理例外的管道,可以從HttpContext.Features拿取例外資訊以及原先的路由

這邊給個算是我比較常用的錯誤處理的方式
web api 有好幾種形式,個人比較常接觸的是rest api
rest api 有一個概念是通過HttpStatusCode 來回應使用者相對應的訊息
相較於全部回傳200 ok,並自定義錯誤代碼
REST 的設計活用了http的設計,並給更加語意化的回應
假設在三層式的架構中
使用者所帶入的參數錯誤,可能會需要回傳400的錯誤
如果成功可能會回傳200 的成功
如過不透過ExceptionHandler,或是自訂的錯誤處理Middleware的話
只能在Service層中回傳IHttpResult 這類的狀態,
這樣會讓表現層/框架的東西滲入業務邏輯層,所以我個人不喜歡
但透過ExceptionHandler加上自訂Exception,我們可以做到一些有趣的事

我們先自訂一個例外
WebApiException.cs

public class WebApiException: Exception
{
    public HttpStatusCode StatusCode { get; } = HttpStatusCode.InternalServerError;
    public string Msg { get; } = "系統發生錯誤";

    public WebApiException(HttpStatusCode statusCode, string msg)
    {
        StatusCode = statusCode;
        Msg = msg;
    }
}

StatusCode 表示我們希望使用者看見的HttpStatusCode
Msg 是錯誤訊息

原諒我懶惰,直接把middleware寫在裡面
program.cs

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

var app = builder.Build();
// Configure the HTTP request pipeline.
app.UseExceptionHandler(b => b.Run(async context =>
{
    var exception = context.Features.Get<IExceptionHandlerFeature>()!.Error;
    object response;
    if (exception is WebApiException httpException)
    {
        context.Response.StatusCode = (int)httpException.StatusCode;

        response = new
        {
            Message = httpException.Msg
        };
    }
    else
    {
        response = new 
        {
            Message = "系統發生錯誤"
        };
    }

    context.Response.ContentType = "application/json; charset=utf-8";
    await context.Response.WriteAsync(JsonSerializer.Serialize(response));
}));
app.UseHttpsRedirection();
app.UseRouting();

app.MapControllers();
await app.RunAsync();

我們透過ExceptionHandler的middleware
自訂了發生例外後的管道
當例外是我們自訂的WebApiException時會將回應的HttpStatusCode與訊息設為自訂回應
而其他例外則會回傳500 InternalServerError跟 系統發生錯誤的錯誤訊息
阻絕使用者知道詳細的錯誤訊息並給予一致的回應
通常會搭配log來記錄其他例外,這邊先跳過

我們加個endpoint來看看效果

// ... 省略

app.UseRouting();

app.MapGet("/Test/{id}", ( int id) =>
{
    if (id == 1)
    {
        throw new WebApiException(HttpStatusCode.BadRequest, "錯誤");
    }
});

app.MapControllers();
await app.RunAsync();

https://ithelp.ithome.com.tw/upload/images/20221011/20109549pR11aslVoU.png

錯誤跟訊息如我們想像,回傳了BadRequest,跟自訂訊息

完賽感言

必須承認,後面的文章其實都有點混
每年都說要提早準備
結果都是每天趕文章
希望明年能夠好好準備一篇真正有價值的主題


上一篇
[Day29] 靜態檔案,StaticFile
系列文
擁抱 .Net Core30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言